把成果帶走,再開始下一次討論。
昨天把表單、API 和 AI 串起來,今天接著補上複製成果與重新討論,再從頭確認操作和整理出來的內容。
問題摘要、候選需求與待確認事項,這三個是主要的內容,把討論背景和三輪問答一起放進去,在討論的時候能更清楚當時的理由是什麼,接著我們來新增複製討論稿的功能:
新增 app/shared/discussion-text.ts,用一般函式把資料整理成有標題、清單與換行的文字。以下節錄候選需求的部分,完整函式放在文末附錄:
// 候選需求
const requirements = result.requirements.length === 0
? ["資訊尚不足以形成候選需求,請查看待確認事項。"]
: result.requirements.flatMap((item, index) => [
`### ${index + 1}. ${item.content}`,
`理由:${item.reason}`,
`User Story:${item.userStory}`,
"建議驗收條件:",
...item.acceptanceCriteria.map((criterion) => `- ${criterion}`),
"",
]);
上面片段負責把傳入的資料轉成文字,最後用 join("\n") 接成一整個段落。
回到 app/components/requirements-assistant.tsx,匯入函式:
import { formatDiscussionDraft } from "../shared/discussion-text";
在外層 RequirementsAssistant 裡,從已完成的 session 計算 discussionText,再加入複製事件:
type CopyState = { status: "idle" | "pending" | "success" | "error" };
// 放在 RequirementsAssistant 內。
const [copy, setCopy] = useState<CopyState>({ status: "idle" });
const [resetOpen, setResetOpen] = useState(false);
const copying = useRef(false);
const copyPending = copy.status === "pending";
const discussionText = session.stage === "result"
? formatDiscussionDraft(session.background, session.history, session.result) : "";
async function handleCopy() {
if (copying.current || resetOpen || session.stage !== "result") return;
copying.current = true;
setCopy({ status: "pending" });
try {
if (!navigator.clipboard?.writeText) throw new Error("瀏覽器不支援自動複製。");
await navigator.clipboard.writeText(discussionText);
setCopy({ status: "success" });
} catch {
setCopy({ status: "error" });
} finally {
copying.current = false;
}
}
說明:
discussionText 從訪談結果計算出來。copy 用來顯示複製狀態。copying.current 則在事件裡立即擋住重複操作。resetOpen 是確認視窗的開關。複製用瀏覽器的 navigator.clipboard.writeText() 功能,等 Promise 成功才顯示成功提示。Clipboard API 需要安全的執行環境,正式網站通常使用 HTTPS,本機開發的 localhost 也可視為可信任來源,瀏覽器可能會拒絕寫入,所以需要處理失敗。
在結果區塊下方接上複製按鈕:
<Button type="button" variant="contained" onClick={handleCopy} disabled={copyPending}>
{copyPending ? "複製中…" : "複製討論稿"}
</Button>
{copy.status === "success" ? (
<Alert severity="success" role="status">已複製討論稿,包含背景與訪談紀錄。</Alert>
) : null}
按鈕放在 session.stage === "result" 的區塊內,與 DraftResult 一起顯示,事件onClick={handleCopy},讓使用者點擊才執行。
複製失敗的時候顯示同一份文字,讓使用者手動全選複製:
{copy.status === "error" ? (
<Stack spacing={2}>
<Alert severity="warning">自動複製失敗,請在下方文字框全選後手動複製。</Alert>
<TextField
id="manual-copy" label="手動複製討論稿" multiline minRows={8} maxRows={16} fullWidth
value={discussionText} slotProps={{ input: { readOnly: true } }}
helperText="點入文字框,按 Ctrl+A 或 Command+A 全選,再按 Ctrl+C 或 Command+C 複製。"
/>
</Stack>
) : null}
這個欄位使用 readOnly,保留點選、選取與複製的能力。
接著加上重新開始,目標是清空背景、當前回答、問答紀錄、結果和狀態提示,回到最初的表單。
要清掉的資料都在外層 RequirementsAssistant,所以我們把重設事件也放在這裡,內層元件會收到新的 props,跟著顯示空白表單。
function handleRequestReset() {
if (sending.current || copying.current) return;
setResetOpen(true);
}
function handleReset() {
if (sending.current || copying.current) return;
setBackground({ problem: "", audience: "", currentProcess: "" });
setSession({ stage: "background" });
setAnswerDraft("");
setRequest({ status: "idle" });
setCopy({ status: "idle" });
setResetOpen(false);
}
如果只把 session.stage 改回 "background",這樣背景草稿還是舊的,複製提示也可能留下來。所以重新開始要同時檢查本次討論有哪些資料。
重設前我們用 MUI 的 Dialog 彈出一個確認視窗,讓使用者有機會取消、先複製成果:
const hasData = session.stage !== "background"
|| Object.values(background).some((value) => value.length > 0);
<Button type="button" variant="outlined" onClick={handleRequestReset}
disabled={pending || copyPending || !hasData}>
重新開始
</Button>
<Dialog open={resetOpen} onClose={() => setResetOpen(false)}
aria-labelledby="reset-title" aria-describedby="reset-description">
<DialogTitle id="reset-title">重新開始這次討論?</DialogTitle>
<DialogContent>
<DialogContentText id="reset-description">
將清空背景、回答、問答紀錄與討論稿。需要保留成果時,請先取消並複製討論稿。
</DialogContentText>
</DialogContent>
<DialogActions>
<Button type="button" onClick={() => setResetOpen(false)} autoFocus>取消</Button>
<Button type="button" onClick={handleReset} disabled={pending || copyPending}>清空並重新開始</Button>
</DialogActions>
</Dialog>
hasData 放在元件的 return 前,按鈕與 Dialog 放在回傳的 JSX,按取消關閉視窗,按確認執行重設。
API 等待或複製尚未完成時,暫時不能重新開始。這樣可以先讓當前操作結束,再清空資料,避免舊請求回來後又把畫面切回訪談。
昨天範例使用的 google/gemini-2.5-flash 是付費模型,我們改用 OpenRouter 的免費模型路由,在專案根目錄的 .env 修改:
OPENROUTER_MODEL=openrouter/free
openrouter/free 會依請求需要的能力,從可用的免費模型中選擇。本次保留 JSON 模式與 provider: { require_parameters: true },伺服器仍檢查回應格式與輪次,實際使用的模型與回答可能不同。
免費模型仍需要有效金鑰,也有請求次數限制。完整三輪訪談會呼叫模型四次,重試也可能占用次數,測試時記得確認自己的帳戶限制,修改 .env 後要重新啟動伺服器。
完整程式放在最後,在專案根目錄執行 npm run dev,開啟終端機顯示的網址。
接著我們用交班需求案例測試,畫面中的問題與結果是 OpenRouter 免費模型實際回傳,題目每次可能不同。
| 欄位 | 內容 |
|---|---|
| 想解決的問題 | 想讓店裡交班不要漏事情,第一版先不做庫存自動扣除 |
| 誰會用 | 早班與晚班店員,偶爾由店長協助確認 |
| 目前如何處理 | 口頭交班,沒有留下紀錄,忙的時候常漏掉待辦事項 |
按「開始訪談」,依當次問題回答:不知道的責任人、權限或原因,直接寫不知道,三輪回答完成後,畫面切換成需求討論稿,不再多問第四題。
在 Chrome 開發者工具的 Network 篩選 clarify,可查看每次送出的 Payload:
| 操作 | history 筆數 |
成功後的畫面 |
|---|---|---|
| 開始訪談 | 0 | 第 1 / 3 題 |
| 回答第一題 | 1 | 第 2 / 3 題 |
| 回答第二題 | 2 | 第 3 / 3 題 |
| 回答第三題 | 3 | 需求討論稿 |
正常完成一次訪談共四次模型請求,三輪是三個回答。結果的 Response 應是 kind: "result",包含摘要、候選需求與待確認事項。

上圖是實際 AI 討論稿與開發者工具,藉由 /api/clarify 的回應核對結果階段。
第二題填好回答後,在 Network 暫時選 Offline,再按送出。這次實測失敗後同一題、回答草稿與已完成的第一輪紀錄都保留。恢復 No throttling 後重試,送出的 history 仍是兩筆,成功後才把第二輪加入畫面紀錄。
advance() 會組合本次要送出的資料,拿到有效回應後才更新 session.history、清空回答,所以失敗的回答不會重複追加成已完成的問答。
討論稿出現後按「複製討論稿」,等成功提示出現,再貼到文字編輯器。檢查複製文字,應該要包含背景、三輪問答、摘要、需求理由、User Story、建議驗收條件與待確認事項。

上圖保留實際討論稿與複製成功提示。
接著按「重新開始」。如果按取消,結果與複製提示會保留,當再開啟確認視窗,按「清空並重新開始」,背景、回答、紀錄、結果與提示一起清空,回到初始表單。

上圖是清空前的確認視窗,提醒需要保留成果的人先取消並複製。
這個小專案從背景表單開始,讓 AI 追問三輪,再整理成可以帶走的討論稿。受控輸入、state、props、事件、陣列更新和 MUI,都用在這個流程裡。
明天回到這三十天的學習,整理我們學習處理哪些問題,以及哪些觀念。
https://github.com/hy9811/ai-requirements-assistant